這次要學甚麼?
這次透過一個範例,使用scaleLinear()
做出可以縮放的分布圖
首先,
本篇是基於這個範例時做出來的 Efrat Vilenski - "Scatter plot with zoom"
另外建立於 Observable 可以直接編輯預覽,請搭配使用
我們要將一組具有可轉化為二維座標的資訊,以及表現資料群集大小的屬性
顯示在一個帶有 x-y 軸的分布圖表中
並可放大區塊檢視
首先,我們透過以下方法
隨機產生一組資料
為了呈現差異,我們讓 x, y 座標分布在 0~1000
之間
而每一個圓半徑大小介於 1~200
unction createData(size) {
let data = [];
for (let i = 0; i < size; ++i) {
data.push({
x: Math.random() * 1000,
y: Math.random() * 1000,
size: Math.floor(Math.random() * 200 + 1)
});
}
return data;
}
接著,我們可以透過
d3.min(iterable[, accessor])
d3.max(iterable[, accessor])
d3.median(iterable[, accessor])
因為我們的每一筆資料是物件 { x, y, size}
因此,在使用時,需要定義 accessor 傳入處理
就如同以下方式:
maxData = {
x: d3.max(data, d => d.x),
y: d3.max(data, d => d.y),
size: d3.max(data, d => d.size)
};
minData = {
x: d3.min(data, d => d.x),
y: d3.min(data, d => d.y),
size: d3.min(data, d => d.size)
};
medianData = {
x: d3.median(data, d => d.x),
y: d3.median(data, d => d.y),
size: d3.median(data, d => d.size)
};
另外,還有一個經常用的方法,目前我常看到的是結合用在 selection.domain()
中
這個方法就是提供一組資料,最小值與最大值,以 [min, max]
作為回傳的方法:d3.extent(iterable[, accessor])
因為 scaling 的時候,會需要知道是從哪一組資料區間 (domain),對應轉換成另一組資料區間 (range)
像是長度的單位轉換
這裡,則運用在坐標軸單位轉換,與資料在座標上位置的轉換
為了提供駔標軸顯示的範圍,我們在畫圖的 svg 上,會預留一些空間 (margin)
因此,長寬會再分成整體與繪製分布圖區塊兩者的長寬
如前面所提,我們要進行坐標軸單位轉換,與資料在座標上位置的轉換
兩者的轉換方式都是連續而且是線性的 (y = ax + b
)
因此,我們使用到 d3.scaleLinear()
方法
如果
d3.scaleLinear()
沒有再指定 domain 與 range 的話,預設皆為[0, 1]
x()
: 產生一個方法,做線性轉換;取得所有資料中 x 座標的最小值與最大值,對應至 x 軸顯示的起點與終點x = d3.scaleLinear()
.domain(extentX) // extentX = d3.extent(data, d => d.x)
.range([0, gWidth]);
y()
:產生一個方法,做線性轉換;取得所有資料中 y 座標的最小值與最大值,對應至 y 軸顯示的起點與終點y = d3.scaleLinear()
.domain(extentY) // extentY = d3.extent(data, d => d.y)
.range([0, gHeight])
cx()
:產生一個方法,做線性轉換;取得所有資料中 x 座標的最小值與最大值,對應至資料 x 座標於 x 軸上顯示的位置cx = d3.scaleLinear()
.domain(extentX) // extentX = d3.extent(data, d => d.x)
.range([margin.left, gWidth + margin.left])
cy()
:產生一個方法,做線性轉換;取得所有資料中 y 座標的最小值與最大值,對應至資料 y 座標於 y 軸上顯示的位置cy = d3.scaleLinear()
.domain(extentY) // extentY = d3.extent(data, d => d.y)
.range([margin.top, gHeight + margin.top])
size()
:產生一個方法,做線性轉換;取得所有資料的 size,對應至資料顯示的範圍大小 (此處使用最大至圖表寬的 1/20)size = d3.scaleLinear()
.domain(extentSize) // extentSize = d3.extent(data, d => d.size)
.range([1, gWidth / 20])
這裡簡單的直接透過 d3.axisTop()
與 d3.axisLeft()
傳入 scale 方法,建立座標軸
這裡概述實作中的幾點
transform
來做位移<svg><defs>
區塊,定義了 clip-path
可被參照使用,超出 clip-path
範圍的就像遮罩一樣被裁切掉d3.brush()
建立可選取區塊,並註冊 brush 的 'end'
事件,觸發時進行座標軸與資料座標的更新轉換'dblclick'
的 event listener,觸發時,要還原至縮放前的大小與位置function setupCanvas() {
// 1. Clear last painted
d3.select('.canvas').selectAll('svg').remove();
// 2. Create svg
const svg = d3.select('.canvas').append('svg')
.attr('width', width)
.attr('height', height)
.append('g')
.attr('transform', `translate(${margin.left}, ${margin.top})`);
// 3. Define clip-path to clip graph out of the bounds
svg.append('defs')
.append('svg:clipPath')
.attr('id', 'clip')
.append('svg:rect')
.attr('width', gWidth)
.attr('height', gHeight)
.attr('x', 0)
.attr('y', 0);
// 4. Append x axis
svg.append('g')
.attr('id', 'axis_x')
.call(xAxis);
// 5. Append y axis
svg.append('g')
.attr('id', 'axis_y')
.attr('transform', `translate(0,0)`)
.call(yAxis);
return svg;
}
svg = setupCanvas();
function setupScatter() {
// 6. Create scatter section to place circles
const scatter = svg.append('g')
.attr('id', 'scatterplot')
.attr('clip-path', 'url(#clip)')
.on('dblclick', onDblClicked);
// 7. Add circles
scatter.selectAll('.dot')
.data(data)
.join('circle')
.attr('class', 'dot')
.attr('r', d => size(d.size))
.attr('cx', d => cx(d.x))
.attr('cy', d => cy(d.y))
.attr('opacity', 0.5)
.style('fill', '#d989d6');
return scatter;
}
scatter = setupScatter();
function setupBrush() {
const brush = d3.brush()
.extent([[0,0],[gWidth, gHeight]])
.on('end', onBrushEnd);
return brush;
}
brush = setupBrush();
scatter.append('g')
.attr('class', 'brush')
.call(brush);
function onBrushEnd(ev) {
const s = ev.selection;
if (!!s) {
x.domain([s[0][0], s[1][0]].map(x.invert, x));
y.domain([s[0][1], s[1][1]].map(y.invert, y));
cx.domain([s[0][0], s[1][0]].map(cx.invert, cx));
cy.domain([s[0][1], s[1][1]].map(cy.invert, cy));
scatter.selectAll('.brush').call(ev.target.clear);
zoom();
}
}
function zoom() {
scatter.transition().duration(750);
svg.select('#axis_x').transition().call(xAxis);
svg.select('#axis_y').transition().call(yAxis);
scatter.selectAll('circle')
.transition()
.attr('cx', d => cx(d.x))
.attr('cy', d => cy(d.y))
.attr('r', d => size(d.size));
}
function onDblClicked() {
x.domain(extentX);
y.domain(extentY);
cx.domain(extentX);
cy.domain(extentY);
svg.select('#axis_x').transition().call(xAxis);
svg.select('#axis_y').transition().call(yAxis);
// avoid circular definition
d3.select('#scatterplot')
.selectAll('circle')
.transition()
.attr('cx', d => cx(d.x))
.attr('cy', d => cy(d.y))
.attr('r', d => size(d.size));
}
做完以後發現,如果要在縮放的時候,讓表示資料範圍大小圓形,也能跟著放大,用 .zoom()
實現應該會更簡單直覺
下次就繼續來優化這個範例!